Skip to content

Conversation

@soyuka
Copy link
Contributor

@soyuka soyuka commented Dec 29, 2025

Summary

Introduces interface-based abstractions for schema generation and discovery components, enabling extensibility and proper dependency injection patterns. This allows integration with external schema libraries (e.g., API Platform) and custom discovery implementations.

Motivation and Context

The current implementation tightly couples schema generation to PHP attributes and hardcodes discoverer instantiation within loaders. This makes it difficult to:

This PR decouples these concerns by introducing interfaces and moving object construction responsibilities to the Builder.

Breaking Changes

Minimal breaking changes:

  • SchemaGenerator::generate() now accepts \Reflector instead of \ReflectionMethod|\ReflectionFunction
  • DiscoveryLoader constructor signature changed (internal usage only, constructed by Builder)

Users relying on default Builder behavior are not affected.

I suggest that the Discoverer (and probably the DiscoveryLoader) should be marked @internal and some of the classes be marked as final. I'm unsure if you'd merge this changes as they may break current implementations, let me know.

Types of changes

  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Changes:

  • Added SchemaGeneratorInterface with generate(\Reflector): array method
  • Added DiscovererInterface (marked @internal) with discover() method
  • SchemaGenerator now implements SchemaGeneratorInterface
  • Discoverer and CachedDiscoverer now implement DiscovererInterface (marked @internal and @final)
  • Builder now constructs and injects Discoverer into DiscoveryLoader (proper DI)
  • Builder automatically decorates Discoverer with CachedDiscoverer when cache is configured
  • DiscoveryLoader simplified to pure loader (no longer factory)
  • Added Builder::setSchemaGenerator() and Builder::setDiscoverer() for custom implementations

Future possibilities:

  • Custom schema providers (e.g., ApiPlatformSchemaProvider)
  • Alternative discovery strategies
  • Class-based schema generation (currently throws \BadMethodCallException) (note that currently a tool is defined using a method or a function but in the future we). open the possibility that a tool could be referenced as an invoke-able class or any other method).

Also relates to #153 (indeed the #153 PR is missing the ArrayLoader change and looking at the code its unclear that the schema generator is used there as well as in the discoverer).

* to improve performance when discovery is called multiple times.
*
* @internal
* @final
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should I mark the class final directly?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, We will merge this PR in 0.3.0 anyways.

@soyuka soyuka force-pushed the dependency-injection branch 3 times, most recently from 8179fb4 to fd0aa5f Compare December 29, 2025 09:32
@soyuka soyuka force-pushed the dependency-injection branch from fd0aa5f to 9c63ce2 Compare December 29, 2025 12:56
Nyholm
Nyholm previously approved these changes Dec 30, 2025
Copy link
Contributor

@Nyholm Nyholm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great. I like to see more @internal and @final.

* to improve performance when discovery is called multiple times.
*
* @internal
* @final
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, We will merge this PR in 0.3.0 anyways.

@Nyholm Nyholm added this to the 0.3.0 milestone Dec 30, 2025
@Nyholm Nyholm added the BC Break Breaking the Backwards Compatibility Promise label Dec 30, 2025
@soyuka
Copy link
Contributor Author

soyuka commented Dec 30, 2025

we should make a pass against all the classes of the sdk before tagging a major stable version to check usage of final/@internal

I'm concerned about big diffs though so maybe I could prepare an issue with that?

@chr-hertel
Copy link
Member

Sounds good to me :)

@soyuka
Copy link
Contributor Author

soyuka commented Dec 31, 2025

WDYT about this one @chr-hertel could it be merged?

Copy link
Member

@chr-hertel chr-hertel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, if this enables you to adopt the SDK in API Platform we should def get this in - makes sense, thanks! 👍

Only minor stuff and we could get it in :)

@soyuka
Copy link
Contributor Author

soyuka commented Jan 3, 2026

Actually this would make the use of other libraries so that each of them could provide bridges to work with the php-sdk:

LLM generated examples for API Platform, Spiral/json-schema-generator or wol-soft/php-json-schema-model-generator

Custom Schema Generation Examples

The MCP PHP SDK now supports custom schema generators through the SchemaGeneratorInterface. This allows you to integrate external schema generation libraries for complex scenarios.

Why Use Custom Schema Generators?

  • Complex Type Mapping: Handle sophisticated PHP types (generics, unions, intersections)
  • External Libraries: Integrate with existing JSON Schema tooling
  • Custom Attributes: Support non-MCP attribute systems (API Platform, Symfony Validator, etc.)
  • Advanced Features: Support JSON Schema features like $ref, $defs, allOf, anyOf, etc.

Example 1: Using API Platform Schema Factory

API Platform provides a powerful schema factory for generating JSON Schemas from PHP classes with extensive metadata support.

<?php

use ApiPlatform\JsonSchema\SchemaFactoryInterface;
use Mcp\Capability\Discovery\SchemaGeneratorInterface;
use Mcp\Server\Builder;

class ApiPlatformSchemaGenerator implements SchemaGeneratorInterface
{
    public function __construct(
        private SchemaFactoryInterface $schemaFactory,
    ) {}

    public function generate(\Reflector $reflection): array
    {
        if (!$reflection instanceof \ReflectionMethod && !$reflection instanceof \ReflectionFunction) {
            throw new \InvalidArgumentException('Only methods and functions are supported');
        }

        $properties = [];
        $required = [];

        foreach ($reflection->getParameters() as $parameter) {
            $paramType = $parameter->getType();
            
            // For complex types, use API Platform to generate schema
            if ($paramType instanceof \ReflectionNamedType && !$paramType->isBuiltin()) {
                $className = $paramType->getName();
                $schema = $this->schemaFactory->buildSchema($className);
                $properties[$parameter->getName()] = $schema['properties'] ?? [];
            } else {
                // Fallback to simple types
                $properties[$parameter->getName()] = [
                    'type' => $this->mapPhpTypeToJsonSchema($paramType),
                ];
            }

            if (!$parameter->isOptional()) {
                $required[] = $parameter->getName();
            }
        }

        return [
            'type' => 'object',
            'properties' => $properties,
            'required' => $required,
        ];
    }

    private function mapPhpTypeToJsonSchema(?\ReflectionType $type): string
    {
        if (!$type instanceof \ReflectionNamedType) {
            return 'string';
        }

        return match ($type->getName()) {
            'int' => 'integer',
            'float' => 'number',
            'bool' => 'boolean',
            'array' => 'array',
            default => 'string',
        };
    }
}

// Usage with Builder
$schemaFactory = /* get API Platform schema factory */;
$generator = new ApiPlatformSchemaGenerator($schemaFactory);

$server = Builder::create()
    ->setSchemaGenerator($generator)
    ->discover(__DIR__ . '/tools')
    ->build();

Example 2: Using php-json-schema-model-generator

The php-json-schema-model-generator library can generate schemas from PHP classes with docblocks and type hints.

<?php

use PHPModelGenerator\SchemaGenerator\SchemaGeneratorInterface as PHPSchemaGeneratorInterface;
use Mcp\Capability\Discovery\SchemaGeneratorInterface;
use Mcp\Server\Builder;

class PHPModelSchemaGenerator implements SchemaGeneratorInterface
{
    public function __construct(
        private PHPSchemaGeneratorInterface $phpSchemaGenerator,
    ) {}

    public function generate(\Reflector $reflection): array
    {
        if (!$reflection instanceof \ReflectionMethod && !$reflection instanceof \ReflectionFunction) {
            throw new \InvalidArgumentException('Only methods and functions are supported');
        }

        $properties = [];
        $required = [];

        foreach ($reflection->getParameters() as $parameter) {
            $paramType = $parameter->getType();
            
            // If parameter is a class, generate its schema
            if ($paramType instanceof \ReflectionNamedType && class_exists($paramType->getName())) {
                $className = $paramType->getName();
                
                // Generate a temporary JSON Schema file for the class
                $tempSchema = $this->generateSchemaForClass($className);
                $properties[$parameter->getName()] = $tempSchema;
            } else {
                // Simple type
                $properties[$parameter->getName()] = [
                    'type' => $this->getJsonSchemaType($paramType),
                ];
            }

            if (!$parameter->isOptional()) {
                $required[] = $parameter->getName();
            }
        }

        return [
            'type' => 'object',
            'properties' => $properties,
            'required' => $required,
        ];
    }

    private function generateSchemaForClass(string $className): array
    {
        // Use reflection to build a schema from class properties
        $reflection = new \ReflectionClass($className);
        $properties = [];

        foreach ($reflection->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) {
            $type = $property->getType();
            $properties[$property->getName()] = [
                'type' => $this->getJsonSchemaType($type),
                'description' => $this->extractDescription($property),
            ];
        }

        return [
            'type' => 'object',
            'properties' => $properties,
        ];
    }

    private function getJsonSchemaType(?\ReflectionType $type): string
    {
        if (!$type instanceof \ReflectionNamedType) {
            return 'string';
        }

        return match ($type->getName()) {
            'int' => 'integer',
            'float', 'double' => 'number',
            'bool' => 'boolean',
            'array' => 'array',
            default => 'string',
        };
    }

    private function extractDescription(\ReflectionProperty $property): string
    {
        $docComment = $property->getDocComment();
        if (!$docComment) {
            return '';
        }

        // Simple extraction of description from docblock
        preg_match('/@var\s+\S+\s+(.+)/', $docComment, $matches);
        return $matches[1] ?? '';
    }
}

// Usage
$phpSchemaGenerator = /* initialize php-json-schema-model-generator */;
$generator = new PHPModelSchemaGenerator($phpSchemaGenerator);

$server = Builder::create()
    ->setSchemaGenerator($generator)
    ->discover(__DIR__ . '/tools')
    ->build();

Example 3: Spiral JSON Schema Generator

The spiral/json-schema-generator provides advanced schema generation with support for nested types, enums, and more.

<?php

use Spiral\JsonSchema\Generator;
use Mcp\Capability\Discovery\SchemaGeneratorInterface;
use Mcp\Server\Builder;

class SpiralSchemaGenerator implements SchemaGeneratorInterface
{
    public function __construct(
        private Generator $spiralGenerator,
    ) {}

    public function generate(\Reflector $reflection): array
    {
        if (!$reflection instanceof \ReflectionMethod && !$reflection instanceof \ReflectionFunction) {
            throw new \InvalidArgumentException('Only methods and functions are supported');
        }

        $properties = [];
        $required = [];

        foreach ($reflection->getParameters() as $parameter) {
            $paramType = $parameter->getType();
            
            if ($paramType instanceof \ReflectionNamedType) {
                // Use Spiral to generate schema for complex types
                if (class_exists($paramType->getName()) || interface_exists($paramType->getName())) {
                    $schema = $this->spiralGenerator->generate($paramType->getName());
                    $properties[$parameter->getName()] = $schema;
                } else {
                    $properties[$parameter->getName()] = [
                        'type' => $this->mapType($paramType->getName()),
                    ];
                }
            } elseif ($paramType instanceof \ReflectionUnionType) {
                // Handle union types with anyOf
                $types = [];
                foreach ($paramType->getTypes() as $type) {
                    if ($type instanceof \ReflectionNamedType) {
                        $types[] = ['type' => $this->mapType($type->getName())];
                    }
                }
                $properties[$parameter->getName()] = [
                    'anyOf' => $types,
                ];
            }

            if (!$parameter->isOptional()) {
                $required[] = $parameter->getName();
            }
        }

        return [
            'type' => 'object',
            'properties' => $properties,
            'required' => $required,
        ];
    }

    private function mapType(string $typeName): string
    {
        return match ($typeName) {
            'int' => 'integer',
            'float', 'double' => 'number',
            'bool' => 'boolean',
            'array' => 'array',
            'object' => 'object',
            'null' => 'null',
            default => 'string',
        };
    }
}

// Usage
$spiralGenerator = new Generator();
$generator = new SpiralSchemaGenerator($spiralGenerator);

$server = Builder::create()
    ->setSchemaGenerator($generator)
    ->discover(__DIR__ . '/tools')
    ->build();

Benefits

  1. Flexibility: Choose the schema generation strategy that fits your needs
  2. Reusability: Leverage existing schema generation infrastructure from your application
  3. Consistency: Use the same schema definitions across your API and MCP tools
  4. Advanced Features: Support complex types, inheritance, polymorphism, and JSON Schema advanced features
  5. Framework Integration: Seamlessly integrate with Symfony, Laravel, API Platform, etc.

Notes

  • The default SchemaGenerator uses PHP reflection and MCP attributes
  • Custom generators are set via Builder::setSchemaGenerator()
  • Both ArrayLoader and DiscoveryLoader use the configured schema generator
  • Schema generators must return a valid JSON Schema object with type: 'object'

Copy link
Member

@chr-hertel chr-hertel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @soyuka!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

BC Break Breaking the Backwards Compatibility Promise Status: Reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants